Skip to content

fix(declarative): allow bundled custom components without AIRBYTE_ENABLE_UNSAFE_CODE - #1083

Merged
Daryna Ishchenko (darynaishchenko) merged 4 commits into
mainfrom
devin/1785220224-fix-bundled-custom-components
Jul 28, 2026
Merged

fix(declarative): allow bundled custom components without AIRBYTE_ENABLE_UNSAFE_CODE#1083
Daryna Ishchenko (darynaishchenko) merged 4 commits into
mainfrom
devin/1785220224-fix-bundled-custom-components

Conversation

@Airbyte-Support

@Airbyte-Support Airbyte Support (Airbyte-Support) commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1082: since 7.23.7, every published manifest-only connector that bundles a components.py fails check/discover/read in Airbyte Cloud with AirbyteCustomCodeNotPermittedError. Found while triaging a production Chargebee -> BigQuery connection that broke the moment source-chargebee 0.10.41 (base image source-declarative-manifest:7.23.7) rolled out.

#1076 added the AIRBYTE_ENABLE_UNSAFE_CODE gate to create_custom_component unconditionally, which lost the provenance distinction the gate has always had: it is meant to protect against untrusted code arriving from a caller (__injected_components_py / a caller-supplied manifest from Connector Builder), not against a connector's own code baked into its own image. Cloud never sets AIRBYTE_ENABLE_UNSAFE_CODE, so trusted first-party connectors started failing.

The gate now keys off manifest provenance instead of the environment alone:

-if not custom_code_execution_permitted():
+manifest_is_untrusted = not self._custom_components_trusted or bool(config.get(INJECTED_MANIFEST))
+if manifest_is_untrusted and not custom_code_execution_permitted():
     raise AirbyteCustomCodeNotPermittedError

Two provenance signals, because untrusted manifests reach the factory two different ways:

  • __injected_declarative_manifest in the config.
  • custom_components_trusted=False, a new kwarg threaded ConcurrentDeclarativeSource -> ModelToComponentFactory (and on to substream_factory).

A manifest read from /airbyte/integration_code/source_declarative_manifest/manifest.yaml inside a published image hits neither signal and may use its bundled components, as before 7.23.7. #1076's threat model (a manifest pointing class_name at any importable callable, e.g. os.getcwd) stays blocked everywhere it applied.

Security follow-up: custom_components_trusted=False on every caller-supplied entry point

Review of the first cut found that the config.get(INJECTED_MANIFEST) signal alone is bypassable, so relying on it at the Connector Builder / source-declarative-manifest remote entry points reopened the #1076 RCE (GHSA-g35w-6r6v-h4r7). Two ways, both verified:

  1. Spec-level components dodge the config check. ConcurrentDeclarativeSource.__init__ builds the spec component with an empty dict config (create_component(SpecModel, spec, dict())), so a CustomConfigTransformation/CustomValidationStrategy in spec.config_normalization_rules reaches create_custom_component with no INJECTED_MANIFEST key.
  2. A manifest can delete its own provenance key. A plain ConfigRemoveFields on ["__injected_declarative_manifest"] runs during config normalization before streams are built, so later Custom* components see a config with the marker gone.

Both routes are the exact attack surface #1076 closed. Because manifest_server.build_source was the only caller passing the trust flag, the other two untrusted entry points were exposed. Fix: pass custom_components_trusted=False in both:

  • connector_builder/connector_builder_handler.py::create_source
  • cli/source_declarative_manifest/_run.py::create_declarative_source (remote-manifest path only; the bundled published connector uses the local-manifest path, which stays trusted)

The trust flag lives on the factory, not in the config, so it is immune to both bypasses. config.get(INJECTED_MANIFEST) is now redundant defense-in-depth rather than the primary control.

Blast radius of the regression

56 monorepo connectors bundle a components.py. Per the live registry, 19 are published on 7.23.7 and broken in Cloud today — apify-dataset, bing-ads, chargebee, google-search-console, greenhouse, instagram, klaviyo, linkedin-ads, monday, nexus-datasets, notion, outreach, paypal-transaction, pinterest, pylon, qualaroo, recurly, retently, slack (full table in #1082 (comment)) — with 13 more on 7.23.6 that would break at their next base-image bump. All 19 need a rebuild once this ships.

Tests

  • fix(declarative): require AIRBYTE_ENABLE_UNSAFE_CODE to instantiate Custom* components from YAML #1076's gate test now injects a manifest into the config and is parametrized over a bundled component class and os.getcwd, asserting the gate fires before class_name is resolved; test_create_custom_component_permitted_for_bundled_manifest covers the image-bundled case with the env var unset; test_create_custom_component_requires_custom_code_enabled_when_untrusted covers the manifest-server flag. The two autouse conftest.py fixtures fix(declarative): require AIRBYTE_ENABLE_UNSAFE_CODE to instantiate Custom* components from YAML #1076 added to force the env var on across the declarative test suites are removed — they masked exactly this regression.
  • New regression tests for the two bypasses, driven through the real Connector Builder create_source entry point with AIRBYTE_ENABLE_UNSAFE_CODE unset: test_create_source_marks_manifest_untrusted (invariant), test_spec_level_custom_component_is_gated, test_config_strip_does_not_bypass_custom_code_gate. All three fail on the pre-follow-up branch and pass with the fix.
  • Verified beyond unit tests by recreating the published source-declarative-manifest image layout with source-chargebee 0.10.41's real manifest.yaml + components.py and running the connector CLI with AIRBYTE_ENABLE_UNSAFE_CODE unset: discover fails on main and returns 27 streams here, CustomFieldTransformation is genuinely instantiated, and a poisoned class_name is still blocked on every caller-supplied route.
  • Also verified end-to-end with source-notion 4.0.18 (base image source-declarative-manifest:7.23.7) using real integration-test credentials, bundled manifest.yaml + components.py, and AIRBYTE_ENABLE_UNSAFE_CODE unset: check fails with AirbyteCustomCodeNotPermittedError on released CDK 7.23.7 and succeeds on this branch.

--components-path gating

The remaining pre-existing gap flagged in review: _register_components_from_file executed a caller-supplied --components-path file via exec_module before any gate could fire, so the trust flag offered no protection on that CLI route. It is now gated: the helper checks custom_code_execution_permitted() and raises AirbyteCustomCodeNotPermittedError before the module is created or executed. Local-operator workflows using --components-path must set AIRBYTE_ENABLE_UNSAFE_CODE=true (same requirement as before #1076's regression for injected code). Covered by test_register_components_from_file_is_gated.

Summary by CodeRabbit

  • Security
    • Improved protection for custom components referenced by declarative manifests supplied at runtime.
    • Custom-component code from untrusted manifests is now blocked unless custom-code execution is explicitly enabled.
    • Bundled connector manifests still allow trusted custom components without requiring the unsafe-code setting.
  • Bug Fixes
    • Ensured custom-component instantiation is gated before any class resolution/loading occurs for untrusted manifests.

Link to Devin session: https://app.devin.ai/sessions/20f99bb504b543fda97972948b0ceecb
Requested by: Daryna Ishchenko (@darynaishchenko)

…BLE_UNSAFE_CODE

Only gate Custom* components when the manifest itself is supplied through the
config, which is the untrusted case. Manifests bundled in a published connector
image are trusted, so their custom components must keep working in environments
that do not set AIRBYTE_ENABLE_UNSAFE_CODE, such as Airbyte Cloud.

Co-Authored-By: syed.khadeer@airbyte.io <cloud-support@airbyte.io>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This CDK Version

You can test this version of the CDK using the following:

# Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@devin/1785220224-fix-bundled-custom-components#egg=airbyte-python-cdk[dev]' --help

# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch devin/1785220224-fix-bundled-custom-components

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /autofix - Fixes most formatting and linting issues
  • /poetry-lock - Updates poetry.lock file
  • /test - Runs connector tests with the updated CDK
  • /prerelease - Triggers a prerelease publish with default arguments
  • /poe build - Regenerate git-committed build artifacts, such as the pydantic models which are generated from the manifest JSON schema in YAML.
  • /poe <command> - Runs any poe command in the CDK environment
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

PyTest Results (Fast)

4 139 tests  +6   4 127 ✅ +6   7m 49s ⏱️ +14s
    1 suites ±0      12 💤 ±0 
    1 files   ±0       0 ❌ ±0 

Results for commit 28a6f6f. ± Comparison against base commit 7e96546.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

PyTest Results (Full)

4 142 tests  +6   4 130 ✅ +6   12m 20s ⏱️ -3s
    1 suites ±0      12 💤 ±0 
    1 files   ±0       0 ❌ ±0 

Results for commit 28a6f6f. ± Comparison against base commit 7e96546.

♻️ This comment has been updated with latest results.

…_components_trusted

Co-Authored-By: syed.khadeer@airbyte.io <cloud-support@airbyte.io>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Runtime verification

Tested by recreating the published source-declarative-manifest image layout (/airbyte/integration_code/source_declarative_manifest/ with source-chargebee 0.10.41's real manifest.yaml + components.py) and running the connector CLI with AIRBYTE_ENABLE_UNSAFE_CODE unset, on main (7e96546) vs this branch.

The reported breakage is reproduced on main and fixed here
main (7e96546) this branch
spec ✅ (proves nothing)
discover AirbyteCustomCodeNotPermittedError, exit 1, no catalog ✅ exit 0, 27 streams (customer, invoice, subscription, item, item_price, plan, coupon, …)
check FAILED: Encountered an error while discovering streams. Error: Custom connector code is not permitted… ✅ reaches Chargebee, FAILED: HTTP 401 Unauthorized (fake API key — no live creds)

The component is genuinely instantiated, not silently skipped:

customer…record_selector.transformations -> source_declarative_manifest.components.CustomFieldTransformation

and a full mocked read of customer folds custom fields correctly:

{"id":"cust_1","email":"a@b.c","updated_at":1700000000,
 "custom_fields":[{"name":"cf_color","value":"blue"}]}     # cf_color removed
Security gate: still holds on every caller-supplied route

A poison module writes /tmp/PWNED at import time; it is deleted before each case, so its presence proves arbitrary code actually ran.

Route (env var unset) Gate fired Code executed
SDM --manifest-path no
SDM config w/ __injected_declarative_manifest no
connector_builder.main read (test_read) no
$ref-nested Custom* in injected manifest no
__injected_components_py no
control: AIRBYTE_ENABLE_UNSAFE_CODE=true n/a yes (catalog built — test not vacuous)

An earlier revision of this PR keyed the gate solely off config[INJECTED_MANIFEST], which left one route ungated: manifest_server passes the caller's manifest as source_config outside the config, so build_source built streams and imported the poisoned class_name with no exception. That is why the second commit adds the explicit custom_components_trusted=False signal at that call site. Note manifest_server/routers/capabilities.py:23 still advertises custom_code_execution from AIRBYTE_ENABLE_UNSAFE_CODE, which now matches the enforced behavior again.

Regression / quality gates
pytest unit_tests/sources/declarative unit_tests/legacy/sources/declarative
  => 1736 passed
pytest unit_tests/sources/declarative/parsers/test_model_to_component_factory.py \
       unit_tests/manifest_server unit_tests/connector_builder
  => 233 passed
ruff check .          => All checks passed!
ruff format --check . => all files already formatted
mypy airbyte_cdk      => Success: no issues found in 454 source files

Not tested: live Chargebee check (no credentials available); the published Docker image was not rebuilt — the image layout was reproduced locally from the CDK Dockerfile.

Devin session

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

ModelToComponentFactory distinguishes trusted bundled manifests from untrusted injected manifests when creating custom components. Trust settings flow through source construction, with manifest server, CLI, and Connector Builder entry points marking runtime manifests as untrusted. Tests cover both paths and gate bypass attempts.

Changes

Custom component trust

Layer / File(s) Summary
Factory trust gate and validation
airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py, unit_tests/sources/declarative/parsers/test_model_to_component_factory.py
Custom component execution is gated for injected or explicitly untrusted manifests before class resolution, propagated to nested factories, and tested for trusted and untrusted cases.
Source trust propagation
airbyte_cdk/sources/declarative/concurrent_declarative_source.py
ConcurrentDeclarativeSource accepts custom_components_trusted and forwards it through both factory construction paths.
Runtime manifest trust boundaries
airbyte_cdk/manifest_server/command_processor/utils.py, airbyte_cdk/cli/source_declarative_manifest/_run.py, airbyte_cdk/connector_builder/connector_builder_handler.py, unit_tests/connector_builder/test_connector_builder_handler.py
API-provided, CLI-supplied, and Connector Builder manifests are marked untrusted, with tests covering spec-level components and removal of manifest provenance fields.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ManifestCaller
  participant ManifestEntryPoint
  participant ConcurrentDeclarativeSource
  participant ModelToComponentFactory
  participant CustomCodeGate
  ManifestCaller->>ManifestEntryPoint: provide runtime manifest
  ManifestEntryPoint->>ConcurrentDeclarativeSource: set custom_components_trusted=false
  ConcurrentDeclarativeSource->>ModelToComponentFactory: forward trust setting
  ModelToComponentFactory->>CustomCodeGate: evaluate custom component
  CustomCodeGate-->>ModelToComponentFactory: permit bundled or reject untrusted component
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement #1082’s provenance-based gate, allowing bundled manifests and blocking injected or caller-supplied custom code.
Out of Scope Changes check ✅ Passed All code and test changes align with the custom-component trust fix; no unrelated scope appears introduced.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: bundled declarative custom components are now allowed without AIRBYTE_ENABLE_UNSAFE_CODE.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1785220224-fix-bundled-custom-components

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@darynaishchenko

Daryna Ishchenko (darynaishchenko) commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

/prerelease

Prerelease Job Info

This job triggers the publish workflow with default arguments to create a prerelease.

Prerelease job started... Check job output.

✅ Prerelease workflow triggered successfully.

View the publish workflow run: https://github.com/airbytehq/airbyte-python-cdk/actions/runs/30345587161

…rusted

Co-Authored-By: Daryna Ishchenko <darina.ishchenko17@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@airbyte_cdk/cli/source_declarative_manifest/_run.py`:
- Around line 215-218: Guard the `_register_components_from_file` call in the
manifest execution flow with the `AIRBYTE_ENABLE_UNSAFE_CODE` check before
loading or executing caller-supplied components. Ensure `--components-path` is
rejected or skipped when unsafe code is disabled, so `spec.loader.exec_module`
cannot run before the `custom_components_trusted=False` protection is applied.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 59d88db1-5dd1-4651-afdc-6c38a541756d

📥 Commits

Reviewing files that changed from the base of the PR and between 8e78bdd and d0e5851.

📒 Files selected for processing (3)
  • airbyte_cdk/cli/source_declarative_manifest/_run.py
  • airbyte_cdk/connector_builder/connector_builder_handler.py
  • unit_tests/connector_builder/test_connector_builder_handler.py

Comment thread airbyte_cdk/cli/source_declarative_manifest/_run.py
…FE_CODE

Co-Authored-By: Daryna Ishchenko <darina.ishchenko17@gmail.com>
@darynaishchenko

Daryna Ishchenko (darynaishchenko) commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

/prerelease

Prerelease Job Info

This job triggers the publish workflow with default arguments to create a prerelease.

Prerelease job started... Check job output.

✅ Prerelease workflow triggered successfully.

View the publish workflow run: https://github.com/airbytehq/airbyte-python-cdk/actions/runs/30357014888

@darynaishchenko
Daryna Ishchenko (darynaishchenko) merged commit 6ce33ea into main Jul 28, 2026
33 of 34 checks passed
@darynaishchenko
Daryna Ishchenko (darynaishchenko) deleted the devin/1785220224-fix-bundled-custom-components branch July 28, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom components in published manifest-only connectors are rejected in Cloud since 7.23.7

3 participants